Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /rapports/[date] — une édition du rapport quotidien : contenu rédigé par3// Claude (édito, faits saillants, analyse, vigilance) + tableau de bord4// chiffré rendu côté serveur depuis le snapshot API·Ka figé au moment de la5// génération (data/rapports/YYYY-MM-DD.json). Le PDF est l'impression de6// CETTE page par Chromium headless (@media print dans globals.css) — une7// seule source de rendu, aucun template dupliqué.8import type { Metadata } from "next";9import Link from "next/link";10import { notFound } from "next/navigation";11import {12 getRapport,13 hasPdf,14 fmtDateLongue,15 fmtNombre,16 METEO_INFO,17 type ServiceSnap,18} from "@/lib/rapports";1920export const dynamic = "force-dynamic";2122type Props = { params: Promise<{ date: string }> };2324export async function generateMetadata({ params }: Props): Promise<Metadata> {25 const { date } = await params;26 const rapport = await getRapport(date);27 if (!rapport) return { title: "Rapport introuvable" };28 return {29 title: `${rapport.contenu.titre} — rapport du ${fmtDateLongue(date)}`,30 description:31 rapport.contenu.sous_titre ??32 `Rapport quotidien du Groupe KA — ${fmtDateLongue(date)}.`,33 };34}3536/** Rend un paragraphe en interprétant uniquement **gras** (aucun autre markdown). */37function Para({ texte, className }: { texte: string; className?: string }) {38 const morceaux = texte.split(/\*\*(.+?)\*\*/g);39 return (40 <p className={className}>41 {morceaux.map((m, i) =>42 i % 2 === 1 ? <strong key={i}>{m}</strong> : m,43 )}44 </p>45 );46}4748function Sparkline({ serie }: { serie: { d: string; n: number }[] }) {49 if (serie.length < 2) return null;50 const w = 120;51 const h = 34;52 const vals = serie.map((p) => p.n);53 const min = Math.min(...vals);54 const max = Math.max(...vals);55 const span = max - min || 1;56 const pts = serie57 .map(58 (p, i) =>59 `${((i / (serie.length - 1)) * (w - 4) + 2).toFixed(1)},${(h - 4 - ((p.n - min) / span) * (h - 8)).toFixed(1)}`,60 )61 .join(" ");62 return (63 <svg64 viewBox={`0 0 ${w} ${h}`}65 className="h-[34px] w-[120px]"66 aria-hidden="true"67 >68 <polyline69 points={pts}70 fill="none"71 stroke="var(--green)"72 strokeWidth="2"73 strokeLinejoin="round"74 strokeLinecap="round"75 />76 </svg>77 );78}7980function Delta({ pct }: { pct: number | null }) {81 if (pct === null) return <span className="klabel">—</span>;82 const up = pct >= 0;83 return (84 <span85 className={`gk-mono text-[12px] font-bold ${up ? "text-green" : "text-[var(--danger,#b3423a)]"}`}86 >87 {up ? "▲" : "▼"} {Math.abs(pct).toLocaleString("fr-CA")} %88 </span>89 );90}9192function LigneService({ s, maxJour }: { s: ServiceSnap; maxJour: number }) {93 const largeur =94 s.jour !== null && maxJour > 0 ? Math.max((s.jour / maxJour) * 100, 1.5) : 0;95 return (96 <li className="grid grid-cols-[110px_1fr_auto] items-center gap-x-3 gap-y-1 border-b border-dashed border-[rgba(20,24,20,0.18)] py-3 last:border-0 sm:grid-cols-[130px_1fr_130px_150px]">97 <p className="gk-display text-[14px] font-bold">{s.nom}</p>98 <div>99 <div className="h-[14px] overflow-hidden rounded-[4px] border border-ink/40 bg-[rgba(20,24,20,0.05)]">100 <div101 className="h-full bg-lime"102 style={{ width: `${largeur}%` }}103 aria-hidden="true"104 />105 </div>106 </div>107 <p className="hidden justify-self-end sm:block">108 <Sparkline serie={s.serie_14j} />109 </p>110 <p className="col-span-2 text-right sm:col-span-1">111 <span className="gk-mono text-[13px] font-bold">112 {fmtNombre(s.jour)}113 </span>{" "}114 <Delta pct={s.delta_pct} />115 </p>116 </li>117 );118}119120export default async function RapportPage({ params }: Props) {121 const { date } = await params;122 const rapport = await getRapport(date);123 if (!rapport) notFound();124 const { contenu, snapshot } = rapport;125 const pdf = await hasPdf(date);126 const meteo = contenu.meteo ? METEO_INFO[contenu.meteo.etat] : null;127 const maxJour = Math.max(...snapshot.services.map((s) => s.jour ?? 0), 0);128 const genereA = new Intl.DateTimeFormat("fr-CA", {129 hour: "2-digit",130 minute: "2-digit",131 timeZone: "America/Toronto",132 }).format(new Date(rapport.genere_le));133134 return (135 <main className="mx-auto max-w-4xl px-4 py-12 sm:px-6">136 {/* fil d'Ariane + actions */}137 <div className="no-print flex flex-wrap items-center justify-between gap-3">138 <Link139 href="/rapports"140 className="gk-mono text-[12px] font-bold tracking-[0.08em] uppercase underline-offset-4 hover:underline"141 >142 ← Tous les rapports143 </Link>144 {pdf && (145 <a146 href={`/api/rapports/${date}/pdf`}147 className="btn btn-primary !min-h-[38px] !px-4 !text-[13px]"148 >149 Télécharger le PDF ↓150 </a>151 )}152 </div>153154 {/* manchette */}155 <header className="mt-8">156 <p className="kicker">Rapport quotidien · Groupe KA × Claude</p>157 <p className="gk-mono mt-3 text-[12.5px] font-bold tracking-[0.08em] text-ink-3 uppercase">158 {fmtDateLongue(date)} · édition de {genereA}159 </p>160 <h1 className="gk-display mt-3 text-[clamp(26px,4.5vw,42px)] leading-[1.04] font-bold tracking-[-0.035em]">161 {contenu.titre}162 </h1>163 {contenu.sous_titre && (164 <p className="mt-4 max-w-2xl text-[15.5px] leading-relaxed text-ink-2">165 {contenu.sous_titre}166 </p>167 )}168 {meteo && contenu.meteo && (169 <p className="gk-card mt-5 inline-flex flex-wrap items-center gap-2 px-4 py-2.5 text-[13.5px]">170 <span aria-hidden="true">{meteo.icone}</span>171 <strong className="gk-display">{meteo.label}</strong>172 <span className="text-ink-2">— {contenu.meteo.resume}</span>173 </p>174 )}175 </header>176177 {/* chiffre du jour + repères */}178 <section className="mt-8 grid gap-4 sm:grid-cols-3">179 {contenu.chiffre_du_jour && (180 <div className="gk-card bg-lime p-5 sm:col-span-1">181 <p className="klabel !text-ink/70">Le chiffre du jour</p>182 <p className="gk-display mt-1 text-[34px] leading-none font-bold tracking-[-0.03em]">183 {contenu.chiffre_du_jour.valeur}184 </p>185 <p className="gk-display mt-2 text-[13.5px] font-bold">186 {contenu.chiffre_du_jour.label}187 </p>188 {contenu.chiffre_du_jour.contexte && (189 <p className="mt-2 text-[12.5px] leading-snug text-ink/80">190 {contenu.chiffre_du_jour.contexte}191 </p>192 )}193 </div>194 )}195 <div className="grid grid-cols-2 gap-4 sm:col-span-2">196 {[197 {198 label: "Enregistrements collectés",199 valeur: fmtNombre(snapshot.totaux.records_jour),200 note: `collecte du ${snapshot.date_donnees}`,201 },202 {203 label: "Total en base",204 valeur: fmtNombre(snapshot.totaux.records_total),205 note: "9 plateformes de données",206 },207 {208 label: "Connecteurs en santé",209 valeur: snapshot.connecteurs210 ? `${snapshot.connecteurs.ok}`211 : "—",212 note: snapshot.connecteurs213 ? `${snapshot.connecteurs.degraded} dégradé(s) · ${snapshot.connecteurs.broken} brisé(s)`214 : "santé indisponible",215 },216 {217 label: "API·Ka (7 jours)",218 valeur:219 snapshot.api_7j.appels !== null220 ? `${fmtNombre(snapshot.api_7j.appels)} appels`221 : "—",222 note:223 snapshot.api_7j.latence_ms !== null224 ? `${Math.round(snapshot.api_7j.latence_ms)} ms de latence moyenne`225 : "",226 },227 ].map((k) => (228 <div key={k.label} className="gk-card p-4">229 <p className="klabel">{k.label}</p>230 <p className="gk-display mt-1 text-[22px] leading-none font-bold">231 {k.valeur}232 </p>233 {k.note && (234 <p className="gk-mono mt-2 text-[10.5px] text-ink-3">235 {k.note}236 </p>237 )}238 </div>239 ))}240 </div>241 </section>242243 {/* éditorial */}244 <section className="mt-10">245 <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">246 L'éditorial de la nuit247 </h2>248 <div className="mt-4 space-y-4 border-l-2 border-lime pl-5">249 {contenu.edito.map((p, i) => (250 <Para251 key={i}252 texte={p}253 className="max-w-3xl text-[15px] leading-[1.75] text-ink-2"254 />255 ))}256 </div>257 </section>258259 {/* la nuit en chiffres — barres + sparklines par plateforme */}260 <section className="mt-10">261 <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">262 La nuit en chiffres263 </h2>264 <p className="gk-mono mt-1 text-[11px] text-ink-3">265 Enregistrements par plateforme (collecte du {snapshot.date_donnees})266 · tendance 14 jours · variation vs la veille267 </p>268 <ul className="gk-card mt-4 px-5 py-2">269 {[...snapshot.services]270 .sort((a, b) => (b.jour ?? 0) - (a.jour ?? 0))271 .map((s) => (272 <LigneService key={s.service} s={s} maxJour={maxJour} />273 ))}274 </ul>275 </section>276277 {/* faits saillants */}278 <section className="mt-10">279 <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">280 Faits saillants281 </h2>282 <ul className="mt-4 grid gap-4 sm:grid-cols-2">283 {contenu.faits_saillants.map((f) => (284 <li key={f.plateforme} className="gk-card p-4">285 <p className="gk-mono text-[11px] font-bold tracking-[0.1em] text-green uppercase">286 {f.plateforme}287 </p>288 <Para289 texte={f.texte}290 className="mt-2 text-[13.5px] leading-relaxed text-ink-2"291 />292 </li>293 ))}294 </ul>295 </section>296297 {/* analyse */}298 {contenu.analyse.map((sec) => (299 <section key={sec.titre} className="mt-10">300 <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">301 {sec.titre}302 </h2>303 <div className="mt-4 space-y-4">304 {sec.contenu.map((p, i) => (305 <Para306 key={i}307 texte={p}308 className="max-w-3xl text-[14.5px] leading-[1.75] text-ink-2"309 />310 ))}311 </div>312 </section>313 ))}314315 {/* vigilance */}316 {(contenu.vigilance?.length ?? 0) > 0 && (317 <section className="mt-10">318 <h2 className="gk-display text-[19px] font-bold tracking-[-0.02em] uppercase">319 À surveiller320 </h2>321 <ul className="gk-card mt-4 space-y-3 border-[var(--amber,#e8a33d)] p-5">322 {contenu.vigilance!.map((v, i) => (323 <li key={i} className="flex gap-3 text-[13.5px] text-ink-2">324 <span aria-hidden="true">⚠️</span>325 <Para texte={v} />326 </li>327 ))}328 </ul>329 </section>330 )}331332 {/* mot de la fin */}333 {contenu.mot_de_la_fin && (334 <blockquote className="gk-display mt-12 border-l-4 border-ink pl-5 text-[19px] leading-snug font-bold tracking-[-0.02em]">335 « {contenu.mot_de_la_fin} »336 </blockquote>337 )}338339 {/* méthode */}340 <p className="gk-mono mt-12 text-[11px] leading-relaxed text-ink-3">341 Méthode : rapport rédigé automatiquement le {fmtDateLongue(date)} à{" "}342 {genereA} par le modèle {rapport.modele} (Anthropic) à partir des343 données mesurées par API·Ka — collectes quotidiennes de 02:00 sur les 9344 plateformes, historique des runs, santé des connecteurs et345 statistiques d'appels. Le tableau « la nuit en chiffres » est rendu346 directement depuis les données, sans passer par le modèle. Aucun347 chiffre inventé ni extrapolé. Le PDF est l'impression fidèle de348 cette page.349 </p>350351 <div className="no-print mt-8 flex flex-wrap gap-3">352 <Link href="/rapports" className="btn btn-ghost">353 ← Tous les rapports354 </Link>355 {pdf && (356 <a href={`/api/rapports/${date}/pdf`} className="btn btn-primary">357 Télécharger le PDF ↓358 </a>359 )}360 </div>361 </main>362 );363}364